feat(inbound): Gmail inbound deployment — composition root, Vercel routes, PG queue, Supabase blob (HT-43) - #43
Conversation
The one-time operator steps to take the merged inbound engine live: GCP Internal OAuth app + Gmail/Pub-Sub provisioning, Supabase Postgres + Storage, Vercel env + cron, an env-var reference, and a post-deploy smoke checklist. Defines the endpoint/env contract the composition root builds to. Real credentials + the consent round-trip remain the operator's action (HT-44). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…3 (HT-43) The production QueueProvider for the RIQ dogfood — a cron-drained durable queue on Supabase Postgres, chosen over Vercel Queues (beta) since it reuses the DB already required. Not a toy table: - migration 013 `queue_jobs`: run_after + locked_until (eligible + leased), attempts, dead_lettered_at (retained, never dropped — invariant #1), a partial unique index for (topic, dedupe_key) dedupe, a ready-jobs index. - enqueue: one durable INSERT (commits before the webhook acks Pub/Sub) with ON CONFLICT DO NOTHING dedupe. - drainOnce: atomic FOR UPDATE SKIP LOCKED claim (concurrent drains never double-process), attempts bumped at claim, ack deletes, retry reschedules with capped exponential backoff, dead-letter on ceiling/explicit — retained. - getStats: ready count / oldest-ready age / dead-letter count for the smoke checklist + alerting. 12 PGlite-backed tests (real Postgres) incl. concurrent-drain no-double-process. Wired only at the composition root (later in HT-43); no engine-core import. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…r (HT-43) Wire the framework-agnostic engine into a deployable Vercel app — the linchpin that turns the merged HT-34..HT-42 engine into a running inbound-mail deployment. - src/composition/config.ts — eager env-contract validation; aggregates all problems into one secret-free boot error (never echoes a value). - src/composition/root.ts — the composition root: constructs every concrete adapter (PostgresDb, Gmail sender/push-verifier/watch/history, the PG queue, Supabase blob) and wires them into createInboxApi (gmailPush + gmailConnect PRESENT here — absent by default on the engine) plus the two cron closures. Per-instance memoized. The refresh-token encryption key is threaded to the token store; JWKS source built once; no secret logged. - src/composition/app.ts — unified handler routing the CRON_SECRET-guarded internal cron endpoints (queue drain, watch maintenance) vs the inbox API; reuses authenticateRequest for the Bearer cron-secret check. - src/providers/adapters/supabase-storage — BlobStore over Supabase Storage (private bucket, signed reads only, service_role server-only). - api/[...path].ts + vercel.json — one catch-all Vercel Node function using the fetch Web Standard export (no node:http bridge needed; Node runtime, not Edge); crons: drain every minute, watch-maintenance daily 06:00 UTC. - scripts/migrate.ts — one-shot migration runner against DATABASE_URL. - runbook: note the Vercel Pro requirement for the sub-daily drain cron and the minted HELPTHREAD_SIGNING_SECRET. Adapter boundary held: engine core imports only interfaces; concretes are wired only at this composition root. Fake-backed tests for the internal endpoints, the blob adapter, and config validation, plus a PGlite integration test driving real requests through the whole composition end to end. Gates green: typecheck + lint + test (40 files / 744 tests). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (6)
🚧 Files skipped from review as they are similar to previous changes (6)
📝 WalkthroughWalkthroughAdds validated environment configuration, Supabase storage, a durable Postgres queue, unified composition-root routing, Vercel cron endpoints, migration tooling, and Gmail inbound deployment documentation with integration tests. ChangesGmail inbound engine
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant Vercel
participant AppHandler
participant InboxAPI
participant CronWork
participant PostgresQueue
Vercel->>AppHandler: forward Request
AppHandler->>InboxAPI: delegate inbox or Gmail request
AppHandler->>CronWork: authenticate cron request
CronWork->>PostgresQueue: drain or inspect queue
PostgresQueue-->>CronWork: return report
CronWork-->>AppHandler: return JSON response
AppHandler-->>Vercel: return Response
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 7
🧹 Nitpick comments (3)
tsconfig.json (1)
14-14: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winInclude the migration runner in typecheck coverage.
The new deployment-critical
scripts/migrate.tsis outside thisincludelist, so the project’s TypeScript validation can pass while the migration command contains a type error. Add the script explicitly or create a dedicated operator-tooling typecheck.As per coding guidelines, continue until the result is verified rather than merely plausible.
Proposed fix
- "include": ["src/**/*.ts", "tests/**/*.ts", "api/**/*.ts"] + "include": ["src/**/*.ts", "tests/**/*.ts", "api/**/*.ts", "scripts/migrate.ts"]🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tsconfig.json` at line 14, Update the TypeScript configuration’s include list to cover scripts/migrate.ts, or add a dedicated typecheck configuration that validates this migration runner. Ensure the resulting typecheck actually processes the migration script and verify the configuration by running the relevant TypeScript validation.Source: Coding guidelines
specs/deploy/gmail-inbound-runbook.md (1)
143-148: 🩺 Stability & Availability | 🔵 TrivialDocument failed-cron handling and alerting.
Vercel does not retry failed Cron invocations; the next scheduled run is a separate invocation. For the daily watch-maintenance job, add an alerting requirement or an internal retry path so a transient 500 does not remain unnoticed until the next day. (vercel.com)
Suggested runbook addition
Vercel Cron invokes these as HTTP GETs; the handlers require the `CRON_SECRET` ... + Vercel does not retry failed invocations; alert on non-2xx responses, + especially for the daily watch-maintenance job.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@specs/deploy/gmail-inbound-runbook.md` around lines 143 - 148, Update the deployment guidance around the watch-maintenance Cron job to document that Vercel does not retry failed invocations. Add an alerting requirement or internal retry path so transient 500 responses are detected and retried or surfaced promptly, rather than remaining unnoticed until the next daily run.Source: MCP tools
src/providers/adapters/supabase-storage/index.test.ts (1)
130-184: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winCover download and signed-URL infrastructure failures.
erroringBucketdefines these failures, but the suite never invokesblob.get()orblob.getSignedUrl()against it. Add both cases to verify those errors are not swallowed.As per coding guidelines, “Convert vague requests into verifiable success criteria ... and continue until the result is verified rather than merely plausible.”
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/providers/adapters/supabase-storage/index.test.ts` around lines 130 - 184, Extend the createSupabaseStorageBlobStore error-propagation tests by adding blob.get() and blob.getSignedUrl() cases using erroringBucket with distinct messages. Assert both promises reject with errors matching the corresponding operation context (“get” or signed-URL) and the underlying storage message, verifying download and signed-URL failures are surfaced.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@specs/deploy/gmail-inbound-runbook.md`:
- Line 6: Update the acceptance phrase in the runbook to hyphenate “end-to-end”
when it functions as a modifier, preserving the surrounding wording and meaning.
- Around line 202-203: Update the job-queue checklist item in the runbook so
retained rows with dead_lettered_at IS NOT NULL are not treated as failures.
Replace that condition with checks for unexpected dead-letter growth, age, or
rate, while preserving the existing monitoring requirement for the oldest ready
job age.
- Line 22: Update the ASCII architecture diagram code fence in the runbook to
specify the text language by using a text fence, while preserving the diagram
content unchanged.
In `@src/composition/config.ts`:
- Around line 215-233: Update resolvePublicBaseUrl in src/composition/config.ts
(lines 215-233) to reject HTTP(S) URLs containing credentials, non-root paths,
queries, or fragments, and return parsed.origin for valid origin-only values.
Add rejection cases covering each non-origin form in
src/composition/config.test.ts (lines 103-110).
In `@src/providers/adapters/postgres-queue/index.test.ts`:
- Around line 295-326: The current PGlite test only verifies sequential
behavior; add PostgreSQL-backed coverage using independent connections and
synchronization to force overlapping claims. Extend the queue drain test around
freshQueue and drainOnce with a barrier or short lease, and verify a stale
worker completing after another worker reclaims the same row cannot corrupt
processing or acknowledgment semantics. Preserve assertions that concurrent
workers handle jobs correctly without unintended duplicate effects.
In `@src/providers/adapters/postgres-queue/index.ts`:
- Around line 306-309: Update the sequential processing loop over claimed rows
and its outcome-write paths to fence stale workers: renew or re-claim each row
immediately before handling it, include the claimed attempts generation (or
lease token) alongside id in every delete/update WHERE clause, and verify that
exactly one row was affected before treating the outcome as successful. Apply
the same protections to the outcome handling around the symbols used at lines
339–373.
- Around line 306-373: The queue outcome handling in the processing loop must be
fenced to the worker’s claim, preventing stale workers from deleting or
rescheduling jobs after lease expiry and reclamation. In
src/providers/adapters/postgres-queue/index.ts lines 306-373, add a claim
generation or lease token to each claim, condition every ack/dead-letter delete
and retry update on that token, verify affected-row counts, and renew or reclaim
leases just in time as needed. In
src/providers/adapters/postgres-queue/index.test.ts lines 295-326, use
independent PostgreSQL connections with a barrier and short lease to verify a
stale worker cannot overwrite or delete a reclaimed attempt; preserve mail
semantics with fixture-based equivalence coverage.
---
Nitpick comments:
In `@specs/deploy/gmail-inbound-runbook.md`:
- Around line 143-148: Update the deployment guidance around the
watch-maintenance Cron job to document that Vercel does not retry failed
invocations. Add an alerting requirement or internal retry path so transient 500
responses are detected and retried or surfaced promptly, rather than remaining
unnoticed until the next daily run.
In `@src/providers/adapters/supabase-storage/index.test.ts`:
- Around line 130-184: Extend the createSupabaseStorageBlobStore
error-propagation tests by adding blob.get() and blob.getSignedUrl() cases using
erroringBucket with distinct messages. Assert both promises reject with errors
matching the corresponding operation context (“get” or signed-URL) and the
underlying storage message, verifying download and signed-URL failures are
surfaced.
In `@tsconfig.json`:
- Line 14: Update the TypeScript configuration’s include list to cover
scripts/migrate.ts, or add a dedicated typecheck configuration that validates
this migration runner. Ensure the resulting typecheck actually processes the
migration script and verify the configuration by running the relevant TypeScript
validation.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: f00912c8-69bf-4aaf-b322-43c24af57910
⛔ Files ignored due to path filters (1)
package-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (19)
api/[...path].tspackage.jsonscripts/migrate.tsspecs/deploy/gmail-inbound-runbook.mdsrc/composition/app.test.tssrc/composition/app.tssrc/composition/config.test.tssrc/composition/config.tssrc/composition/root.test.tssrc/composition/root.tssrc/db/migrate.test.tssrc/db/migrate.tssrc/db/postgres.test.tssrc/providers/adapters/postgres-queue/index.test.tssrc/providers/adapters/postgres-queue/index.tssrc/providers/adapters/supabase-storage/index.test.tssrc/providers/adapters/supabase-storage/index.tstsconfig.jsonvercel.json
| it('two concurrent drainOnce calls never process the same job twice (FOR UPDATE SKIP LOCKED)', async () => { | ||
| const { db, queue } = await freshQueue() | ||
| const jobCount = 10 | ||
| for (let i = 0; i < jobCount; i++) { | ||
| await queue.enqueue(TOPIC, reconcileJob(i)) | ||
| } | ||
|
|
||
| // PGlite is single-connection/in-process, so these two `drainOnce` calls | ||
| // are not necessarily racing on separate backend connections the way two | ||
| // real Supabase-backed Vercel Cron invocations would (see | ||
| // src/db/migrate.ts's `migrate()` doc comment on the same PGlite | ||
| // limitation for true concurrent-lock coverage). What this DOES prove | ||
| // unconditionally, regardless of how the two calls actually interleave: | ||
| // the claim query's WHERE clause (`locked_until IS NULL OR locked_until | ||
| // < now()`, re-checked inside the same atomic UPDATE the FOR UPDATE SKIP | ||
| // LOCKED subquery drives) never lets two calls claim the same row. | ||
| const processedIds: string[] = [] | ||
| const handler: QueueMessageHandler<unknown> = async (message) => { | ||
| processedIds.push(message.id) | ||
| return { kind: 'ack' } | ||
| } | ||
|
|
||
| const [a, b] = await Promise.all([ | ||
| queue.drainOnce({ handlers: { [TOPIC]: handler } }, { batchSize: jobCount }), | ||
| queue.drainOnce({ handlers: { [TOPIC]: handler } }, { batchSize: jobCount }), | ||
| ]) | ||
|
|
||
| expect(a.claimed + b.claimed).toBe(jobCount) | ||
| expect(processedIds).toHaveLength(jobCount) | ||
| // No id appears twice — the union of what each call processed has no overlap. | ||
| expect(new Set(processedIds).size).toBe(jobCount) | ||
| expect(await countRows(db, 'queue_jobs')).toBe(0) |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
This fixture does not exercise overlapping claims.
A single PGlite connection may serialize both drains, so unique processed IDs only prove sequential drains behave correctly. Add a PostgreSQL test using independent connections and a barrier/short lease, including a stale worker completing after another worker reclaims the row.
As per coding guidelines, “changes affecting mail semantics require fixture-proven equivalence.”
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/providers/adapters/postgres-queue/index.test.ts` around lines 295 - 326,
The current PGlite test only verifies sequential behavior; add PostgreSQL-backed
coverage using independent connections and synchronization to force overlapping
claims. Extend the queue drain test around freshQueue and drainOnce with a
barrier or short lease, and verify a stale worker completing after another
worker reclaims the same row cannot corrupt processing or acknowledgment
semantics. Preserve assertions that concurrent workers handle jobs correctly
without unintended duplicate effects.
Source: Coding guidelines
| const claimed = await claimBatch(db, topics, leaseSeconds, batchSize) | ||
| report.claimed = claimed.length | ||
|
|
||
| for (const row of claimed) { |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🔴 Critical | 🏗️ Heavy lift
Fence outcome writes against expired and reclaimed leases.
The whole batch is leased before sequential processing. Once a lease expires, another drainer can reclaim a row, but this stale worker can still delete or overwrite that newer attempt because every outcome matches only id.
Use attempts as a claim generation (or add a lease token) in every outcome WHERE, verify that one row was affected, and renew/claim leases just before sequential handling.
Also applies to: 339-373
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/providers/adapters/postgres-queue/index.ts` around lines 306 - 309,
Update the sequential processing loop over claimed rows and its outcome-write
paths to fence stale workers: renew or re-claim each row immediately before
handling it, include the claimed attempts generation (or lease token) alongside
id in every delete/update WHERE clause, and verify that exactly one row was
affected before treating the outcome as successful. Apply the same protections
to the outcome handling around the symbols used at lines 339–373.
| const claimed = await claimBatch(db, topics, leaseSeconds, batchSize) | ||
| report.claimed = claimed.length | ||
|
|
||
| for (const row of claimed) { | ||
| const handler = deps.handlers[row.topic] | ||
| if (handler === undefined) { | ||
| // Structurally unreachable: claimBatch's `topic IN (...)` list is | ||
| // built from exactly `Object.keys(deps.handlers)`, so every | ||
| // claimed row's topic has a registered handler. Thrown rather | ||
| // than silently skipping a claimed (leased) job. | ||
| throw new Error( | ||
| `createPostgresQueue: claimed job ${row.id} has topic '${row.topic}' with no registered handler`, | ||
| ) | ||
| } | ||
|
|
||
| const message: QueueMessage<unknown> = { | ||
| id: row.id, | ||
| topic: row.topic, | ||
| payload: row.payload, | ||
| attempts: row.attempts, | ||
| enqueuedAt: toDate(row.created_at), | ||
| } | ||
|
|
||
| let result: QueueHandlerResult | ||
| let caughtErrorMessage: string | null = null | ||
| try { | ||
| result = await handler(message) | ||
| } catch (err) { | ||
| // A throw is a retry with no hint (module doc). | ||
| caughtErrorMessage = err instanceof Error ? err.message : String(err) | ||
| result = { kind: 'retry' } | ||
| } | ||
|
|
||
| if (result.kind === 'ack') { | ||
| await db.query('DELETE FROM queue_jobs WHERE id = $1', [row.id]) | ||
| report.acked++ | ||
| continue | ||
| } | ||
|
|
||
| if (result.kind === 'deadLetter') { | ||
| await deadLetterJob(db, row.id, result.reason) | ||
| report.deadLettered++ | ||
| continue | ||
| } | ||
|
|
||
| // result.kind === 'retry': dead-letter once the effective ceiling is | ||
| // reached, otherwise reschedule with exponential backoff (module doc). | ||
| if (row.attempts >= maxAttempts) { | ||
| await deadLetterJob( | ||
| db, | ||
| row.id, | ||
| caughtErrorMessage ?? `createPostgresQueue: exceeded maxAttempts (${maxAttempts})`, | ||
| ) | ||
| report.deadLettered++ | ||
| continue | ||
| } | ||
|
|
||
| const base = result.backoffSeconds ?? baseBackoffSeconds | ||
| const backoffSeconds = Math.min( | ||
| base * 2 ** Math.max(0, row.attempts - 1), | ||
| maxBackoffSeconds, | ||
| ) | ||
| await db.query( | ||
| `UPDATE queue_jobs | ||
| SET locked_until = NULL, run_after = now() + make_interval(secs => $2::float8), last_error = $3, updated_at = now() | ||
| WHERE id = $1`, | ||
| [row.id, backoffSeconds, caughtErrorMessage], | ||
| ) |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🔴 Critical | 🏗️ Heavy lift
Fence queue outcomes with claim ownership and verify lease-expiry races.
Leasing the batch before sequential processing allows rows to expire and be reclaimed, while stale outcome writes still match solely by ID.
src/providers/adapters/postgres-queue/index.ts#L306-L373: condition every delete/update on a claim generation or lease token, verify the affected-row count, and renew or claim leases just in time.src/providers/adapters/postgres-queue/index.test.ts#L295-L326: use independent PostgreSQL connections and a barrier/short lease to prove stale workers cannot overwrite or delete a reclaimed attempt.
As per coding guidelines, “changes affecting mail semantics require fixture-proven equivalence.”
📍 Affects 2 files
src/providers/adapters/postgres-queue/index.ts#L306-L373(this comment)src/providers/adapters/postgres-queue/index.test.ts#L295-L326
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/providers/adapters/postgres-queue/index.ts` around lines 306 - 373, The
queue outcome handling in the processing loop must be fenced to the worker’s
claim, preventing stale workers from deleting or rescheduling jobs after lease
expiry and reclamation. In src/providers/adapters/postgres-queue/index.ts lines
306-373, add a claim generation or lease token to each claim, condition every
ack/dead-letter delete and retry update on that token, verify affected-row
counts, and renew or reclaim leases just in time as needed. In
src/providers/adapters/postgres-queue/index.test.ts lines 295-326, use
independent PostgreSQL connections with a barrier and short lease to verify a
stale worker cannot overwrite or delete a reclaimed attempt; preserve mail
semantics with fixture-based equivalence coverage.
Source: Coding guidelines
…, race defense, docs) - config.ts: PUBLIC_BASE_URL is now validated as a bare origin — reject a path, query, fragment, or embedded credentials (not silently strip them) and return URL.origin, so the redirect_uri / push `aud` concatenations can't be corrupted by a stray path. + rejection tests for each non-origin form. - vercel.json: cap function maxDuration at 50s — below the 60s job lease and the 60s cron interval — so a drain is always killed before its own leases expire and consecutive drains never overlap (defense-in-depth for the queue's lease-reclaim race; the SQL-level claim-generation fence is a tracked follow-up, needing a real two-connection Postgres race test). - tsconfig: typecheck-cover scripts/migrate.ts (deploy-critical operator tool). - supabase-storage: add download + createSignedUrl error-path tests. - runbook: dead-letter smoke check reworded (retained dead-letters are by design — check growth/age/rate, not pass/fail); failed-cron alerting note; the maxDuration<lease constraint; end-to-end hyphen; diagram fence language. Gates green: typecheck + lint + test (40 files / 748 tests). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
|
HT-43 — Deployment: Vercel route + cron + GCP/Pub-Sub runbook + Supabase
Turns the merged HT-34…HT-42 engine into a deployable inbound-mail app. Three commits, one operational unit:
docs(deploy)— the runbook (specs/deploy/gmail-inbound-runbook.md): the one-time GCP/Supabase/Vercel operator steps + the env/endpoint contract + a post-deploy smoke checklist.feat(providers)— the durable PG job queue (src/providers/adapters/postgres-queue/+ migration 013queue_jobs):createPostgresQueue(db)— durableINSERTenqueue with dedupe,FOR UPDATE SKIP LOCKEDlease-based drain, exponential backoff, retained dead-letters,getStats. Chosen over Vercel Queues (beta); the invariant is durable enqueue commits before the webhook acks Pub/Sub (the webhook already does this). 12 PGlite tests.feat(deploy)— the composition root (this review's focus).What the composition root wires
src/composition/config.ts— eager env-contract validation. Aggregates all problems into one boot error; never echoes a secret value (length/shape only).src/composition/root.ts— the one place concrete adapters are constructed and injected:PostgresDb(Supabase 6543 pooler) → every store;createMailboxTokenStore(db, encKey)(refresh-token encryption at rest); the Gmail OAuth token service + outboundEmailSender(per-mailbox token resolved lazily by support address); the Gmail push verifier (JWKS source built once per instance); the PG queue; the connect/consent service;createInboxApiwithgmailPush+gmailConnectpresent (absent-by-default on the engine — wired only here); and the two cron closures. Per-instance memoized.src/composition/app.ts— unified handler: routes the twoCRON_SECRET-guarded internal cron endpoints (queue drain, watch maintenance) and delegates everything else to the inbox API. ReusesauthenticateRequestfor the Bearer cron-secret check (auth before method → no method oracle).src/providers/adapters/supabase-storage/—BlobStoreover Supabase Storage (private bucket, signed reads only,service_roleserver-only).api/[...path].ts+vercel.json— a single catch-all Vercel Node-runtime function using thefetchWeb Standard export (handles all methods; hands us a webRequestdirectly, so nonode:httpbridge is needed — see reviewer notes). Crons: drain every minute, watch-maintenance daily 06:00 UTC.scripts/migrate.ts— one-shot migration runner againstDATABASE_URL.Sacred invariants (verified)
src/api,src/mail,src/store) imports only provider interfaces (type-only) — the only runtime@supabase/*import is the adapter itself; concretes are wired solely at the composition root.HELPTHREAD_TOKEN_ENC_KEYis threaded to the token store and nowhere else.Not in this PR (operator / HT-44)
Real provisioning + the live Google consent are the operator's job — this PR is the turnkey code + runbook. Still pending: the OAuth client (needs the deployed
PUBLIC_BASE_URL), the Pub/Sub push subscription, a dedicated Supabase project, and the real mailbox connect (HT-44).Post-deploy smoke checklist (runbook Part F)
GET /api/v1/conversationswith the Bearer token →200; wrong/no Bearer →401.POST /connect→ aconsentUrlwhoseredirect_uribyte-matches the OAuth client's.mailboxesrow (status=active), amailbox_oauth_tokensrow (ciphertext, not plaintext), agmail_watch_staterow with ahistory_id.dead_lettered_at IS NOT NULL.Testing
typecheck+lint+testall green — 40 files / 744 tests (+4 files / +39 tests). Includes a PGlite integration test that builds the whole composition and drives realRequests through it (inbox path, both cron endpoints, Gmail connect + webhook wiring), plus fake-backed tests for the internal endpoints, the blob adapter, and config validation.Reviewer notes
fetchexport vs thenode:httpbridge: Vercel's Node runtime supportsexport default { fetch(request: Request) }(verified against current Vercel docs), which hands a webRequestdirectly — so the framework-agnosticRequest => Responseengine wires in with no bridge. The dev harness'ssrc/dev/http-adapter.tsbridge stays for the bare-node:httplocal server only.drainHandlersmaps the topic toreconcileHandlervia a single narrowing cast (QueueMessage<unknown>→QueueMessage<GmailReconcileJob>) — the topic string is what guarantees the payload shape; the cast is the honest boundary.BlobStore.existsuses Supabase's nativeexists(); it isn't exercised by the engine today (onlyput/getare) but is implemented + tested for interface completeness.🤖 Generated with Claude Code
Summary by CodeRabbit